标准 Hero
在 Android 中有个词儿叫过渡动画,不同的 Activity 通过为控件设置相同 android:transitionName
来进行页面之间的控件传递效果。
在 Flutter 中也有类似的功能,那就是 hero,它的使用方法也很简单,总结起来就两点:
- 使用
Hero
”包裹“要过渡的 Widget - 给两个 Route 之间过渡的 Widget 设置相同的
tag
so easy~
看代码:
void main() {
runApp(MaterialApp(
routes: {
"/": (BuildContext context) => new HeroTestFirstRoute(),
"/Second": (BuildContext context) => new HeroTestSecondRoute(),
},
initialRoute: "/",
));
}
class HeroTestFirstRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Hero First"),
),
body: Container(
alignment: Alignment.bottomCenter,
color: Colors.lightGreenAccent,
child: GestureDetector(
onTap: () {
Navigator.pushNamed(context, "/Second");
},
child: Hero(
tag: "chenshu",
child: Image.asset(
"assets/images/chenshu.jpg",
width: 100,
height: 100,
fit: BoxFit.contain,
),
),
),
),
);
}
}
class HeroTestSecondRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Hero Second"),
),
body: Container(
alignment: Alignment.topCenter,
color: Colors.yellow,
child: GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Hero(
tag: "chenshu",
child: Image.asset(
"assets/images/chenshu.jpg",
width: 400,
height: 500,
fit: BoxFit.contain,
),
),
),
),
);
}
}
效果如下:
在 Route 跳转的过程中,Flutter 会自动计算出一个 RectTween
(还记得之前讲的 Tween 么),这个 RectTween 就定义了从源 Route 到目标 Route 过程中,Hero 的边界。
我们来看一下 Hero 的构造方法:
const Hero({
Key key,
@required this.tag,
this.createRectTween,
this.flightShuttleBuilder,
this.placeholderBuilder,
this.transitionOnUserGestures = false,
@required this.child,
})
- tag,必填,Flutter 系统就是根据 tag 来确定新旧 Route 之间的 widget 的对应关系的。
- createRectTween,在动画进行中Flutter是依靠Tween来实现,通过createRectTween属性把Tween传给Hero。系统为我们提供了默认的
MeterialRectArcTween
曲线路径 - flightShuttleBuilder,在过渡飞行期间,代替飞行 Widget 的组件
- placeholderBuilder,设置占位符,在组件飞离它曾经处于的位置并且到达目标位置之前,目标处有一处空的地方。 我们可以在此位置添加占位符。
- transitionOnUserGestures,使 hero 动画可以支持 iOS 返回滑动手势,源 Route 和目标 Route 都设置为 true 即可。
- child,页面之间过渡的 Widget。
切记,同一个 Route 里的 Hero tag 不能重复,源 Route 和目标 Route 中的 tag 必须一一对应。
下面几张图说明了 Widget 是如何在 Route 之间过渡的:
过渡之前,源 hero 会在源路由的 widget 树中等待。目标路由尚不存在,叠加层为空。
当转跳开始触发时,会执行以下操作:
- 使用 Material motion 规范中所述的曲线运动计算目标 hero 的路径。现在Flutter知道 hero 在哪里结束。
- 将目标 hero 放置在叠加层中,与源 hero 的位置和大小相同。将 hero 添加到叠加层会更改其 Z 序,以使其出现在所有路由的顶部
- 将源 hero 移出路由。
当 hero 在 Route 之间“飞行”时,其矩形边界使用 hero 中 createRectTween 属性中指定的 Tween 进行动画。默认情况下,Flutter使用 MaterialRectArcTween 的一个实例,该实例沿曲线路径对矩形的对角进行动画处理。
当飞行完成时:
- Flutter 将 hero widget 从叠加层移动到目标路由。叠加层现在是空的。
- 目标 hero 出现在目标路由的最终位置。
- 源 hero 恢复到其路由。
径向 Hero
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
import 'dart:math' as math;
void main() => runApp(MaterialApp(home: RadialExpansionDemo()));
class Photo extends StatelessWidget {
final String photo;
final VoidCallback onTap;
final double width;
const Photo({Key key, this.photo, this.onTap, this.width}) : super(key: key);
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).primaryColor.withOpacity(0.25),
child: InkWell(
onTap: onTap,
child: LayoutBuilder(builder: (context, size) {
return Image.asset(photo, fit: BoxFit.contain);
}),
),
);
}
}
class RadialExpansionDemo extends StatelessWidget {
static const double kMinRadius = 32.0;
static const double kMaxRadius = 128.0;
static const opacityCurve =
const Interval(0.0, 0.75, curve: Curves.fastOutSlowIn);
static RectTween _createRectTween(Rect begin, Rect end) {
return MaterialRectArcTween(begin: begin, end: end);
}
static Widget _buildPage(
BuildContext context, String imageName, String description) {
return Container(
color: Theme.of(context).canvasColor,
child: Center(
child: Card(
elevation: 8,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SizedBox(
width: kMaxRadius * 2,
height: kMaxRadius * 2,
child: Hero(
createRectTween: _createRectTween,
tag: imageName,
child: RadialExpansion(
maxRadius: kMaxRadius,
child: Photo(
photo: imageName,
onTap: () {
Navigator.of(context).pop();
},
),
),
),
),
Text(description,
style: TextStyle(fontWeight: FontWeight.bold),
textScaleFactor: 3.0),
const SizedBox(height: 16)
],
),
),
),
);
}
Widget _buildHero(
BuildContext context, String imageName, String description) {
return Container(
width: kMinRadius * 2,
height: kMinRadius * 2,
child: Hero(
createRectTween: _createRectTween,
tag: imageName,
child: RadialExpansion(
maxRadius: kMaxRadius,
child: Photo(
photo: imageName,
onTap: () {
Navigator.of(context).push(PageRouteBuilder<void>(pageBuilder:
(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Opacity(
opacity: opacityCurve.transform(animation.value),
child: _buildPage(context, imageName, description),
);
});
}));
}),
),
));
}
@override
Widget build(BuildContext context) {
timeDilation = 5.0;
return Scaffold(
appBar: AppBar(
title: const Text('Radial Transition Demo'),
),
body: Container(
padding: EdgeInsets.all(32),
alignment: FractionalOffset.bottomLeft,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_buildHero(context, 'assets/images/chair-alpha.png', "Chair"),
_buildHero(
context, 'assets/images/binoculars-alpha.png', "Binoculars"),
_buildHero(
context, 'assets/images/beachball-alpha.png', "Beach ball"),
],
),
),
);
}
}
class RadialExpansion extends StatelessWidget {
final double maxRadius;
final clipRectSize;
final Widget child;
const RadialExpansion({Key key, this.maxRadius, this.child})
: clipRectSize = 2.0 * (maxRadius / math.sqrt2),
super(key: key);
@override
Widget build(BuildContext context) {
return ClipOval(
child: Center(
child: SizedBox(
width: clipRectSize,
height: clipRectSize,
child: ClipRect(
child: child,
),
),
),
);
}
}
代码解释看这里: